我的系統每天要知道二十幾檔美股和台股的價格:晨報要用、持倉損益要算、異動提醒要比對。付費行情 API 一個月最少十幾美金起跳——這筆固定支出比我整個 LLM 的用量還貴,而它買到的東西(幾十檔股票的日收盤價)其實免費管道就有。
所以我走了免費仔的路:Yahoo Finance 的公開端點。免費的代價很快就來了——某天凌晨的快取更新 job 連續失敗,log 裡整排 429 Too Many Requests。我像個貪心的爬蟲一樣,把二十幾檔股票的請求同時射出去,然後被暫時擋在門外。
免費行情 API 的宿命就是這句話:**抓太兇被擋,抓太慢資料過期。**今天講我怎麼在這兩端之間找到平衡。
這件事的起點其實不是「架構潔癖」,是帳單。我的投資這一塊,對話和批次任務特別多——晨報、持倉損益、選股分析、每次我在 Telegram 隨口問「MU 現在多少」,全都要股價。早期的天真版本是「誰要價格誰自己打 API」:晨報 job 打一輪、損益同步 job 再打一輪、我問一句又打一次。同一天同樣的數據被抓了三、四輪,被 ban 的風險乘以四,而且每個使用方各自處理錯誤,程式碼重複到可笑——更別說如果走付費行情,這種重複抓法是照次數在燒錢。
於是我把它反過來:不再是「每個需求各自去抓」,而是一個排程把價格抓進一份底層快取,所有需求都只讀那份檔案。抓取的次數從「每個消費者各打一輪」壓成「每天固定幾次」,費用與被 ban 風險同時降下來。至於這「幾次」該抓在什麼時候,留到後面〈抓取時機〉一節再談——先記住一個原則:新鮮度需求決定抓取頻率,不是反過來。
改成快取架構後,全世界只有一個地方碰外部 API:

規則很簡單:抓的人只負責抓,用的人只准讀快取。快取是一份 JSON 檔,放在 Workspace 裡,帶著每檔股票的價格、漲跌幅,以及一個台北時間的時間戳。所有下游——晨報、持倉損益、觸發告警——都只看這份檔案,不各自去打外部 API。它的新鮮度是日線級的(最近一次排程抓到的),對我這種非當沖的個人投資決策完全夠用;我不做分鐘級即時,所以也沒讓誰為了「即時」去繞過快取現抓。
這個取捨值得說清楚:我不做分鐘級行情,因為我不當沖。先定義你需要的資料新鮮度,再決定抓取頻率——很多人反過來,先拚命抓,再想資料要幹嘛。
快取更新腳本是一支 Node.js 程式,核心就三件事:限併發、加延遲、留退路。偽碼長這樣:
const CONCURRENCY = 3; // 同時最多 3 個請求
const DELAY_MS = 300; // 每個請求之間隔 300ms
const MAX_RETRY = 2; // 失敗最多重試 2 次,指數退避
// 關鍵:抓取函式「自己把錯誤吞掉」,永遠不 throw——
// 失敗就回「舊值 + stale」或 null,所以整批用 Promise.all 也不會被單檔炸掉
async function fetchOne(sym) {
try {
return await fetchWithRetry(sym, MAX_RETRY); // 成功 → 新報價
} catch (e) {
return oldCache[sym]
? { ...oldCache[sym], stale: true } // 有舊值 → 沿用並標記 stale
: null; // 連舊值都沒有 → 回 null
}
}
async function updateCache(symbols) {
const results = {};
// 把股票清單切成大小為 3 的批次
for (const batch of chunk(symbols, CONCURRENCY)) {
// 每檔各自 catch,Promise.all 不會因為單一檔失敗而整批 reject
const quotes = await Promise.all(batch.map(fetchOne));
batch.forEach((sym, i) => { if (quotes[i]) results[sym] = quotes[i]; });
await sleep(DELAY_MS); // 批次之間喘口氣
}
results._meta = { timestamp_taipei: nowTaipei() };
writeJson('cache/market/stocks_latest.json', results);
}
幾個數字的由來:
併發 3、間隔 300ms 不是什麼精算出來的科學公式。被 429 教育之後,我沒有慢慢去試「到底幾條併發才安全」——直接照「禮貌爬蟲」的常識壓到保守值:同時最多 3 個請求、每批之間停 300ms。二十幾檔股票跑完大約 2–3 秒,對一個凌晨排程來說慢這幾秒毫無代價,被 ban 一天的代價卻是整天的報告全瞎。免費 API 的第一守則:你不是它的客戶,別把它當 SLA 用;與其去試它的底線,不如一開始就客氣。
失敗沿用舊值 + stale 標記是這支腳本最重要的設計。單檔抓取失敗不該讓整份快取開天窗——舊價格加上「這是舊的」標記,遠比空值好。下游看到 stale 旗標可以自己決定要不要警示(這件事在 Day 18 會變成主角)。
每一檔自己 catch,整批才敢用 Promise.all:批內三檔同時射出去,只要每一檔的抓取函式內部自己把例外吞掉、回傳 null 或舊值,整批就不會因為其中一檔 timeout 而集體 reject。我一開始偷懶,讓單檔的例外直接往上冒,結果一檔 timeout 就讓整批重來,反而加倍觸發限流——後來把 try/catch 收進每一檔內部,Promise.all 就穩了。效果跟 Promise.allSettled 一樣(一檔壞不影響其他檔),只是我選擇把隔離做在更裡層的抓取函式,而不是外層的批次收斂。
先誠實講一件事:目前我只有一個免費源(Yahoo 那個端點),還沒做備援源——單源抽風時,靠的是上一節那套「保留舊值 + 標 stale」硬撐,不是切換到第二家。備援源在待辦上,但個人規模還沒到非做不可。不過單源也有它的坑:同一個回應裡「前收盤」和「即時價」是不同欄位,取錯欄意思就差很多,所以我在正規化層明確選欄、寧可寫死也不含糊(這條跟明天 Day 18 的毒數據是同一個病:欄位語意不能想當然)。
抓取的時機不是「每天固定挑個時間」,而是跟著市場的節奏走。以美股一個交易日為軸,快取在三個關鍵點各刷新一次(台北時間):
| 時機 | 對應 | 抓什麼 |
|---|---|---|
| 21:00 開盤 | 美股開盤前後 | 開盤第一批報價;同一支 job 順手把台股收盤價(13:30 已收)帶進來 |
| 01:00 盤中 | 美股盤中 | 盤中更新,讓凌晨的觸發告警看到當日走勢 |
| 04:00 盤後 | 美股收盤 | 抓當日收盤價,餵當天早上的晨報 pipeline(Day 17) |
一支 job 同時抓美股和台股——台股 13:30 收盤早於美股 21:00 開盤,所以台股的當日收盤價在第一次刷新就進了快取。關鍵是沒有行情變動的深夜不抓:沒有新價格的時段抓再多次,也只是重複燒配額、多冒一次被 ban 的險。這正是上一節「降低費用」的具體落地——抓取頻率對著新鮮度需求,不對著焦慮。
**坑一:重試邏輯放大災難。**早期版本失敗就立刻重試、最多五次。遇到 429 的時候,這等於「被擋 → 更用力敲門」,把暫時限流敲成長時間封鎖。
修正的方向是把「所有錯誤一視同仁」改成「按錯誤類型分流」,現在的規則是這樣:
| 錯誤 | 處理 | 理由 |
|---|---|---|
429 限流 |
重試,但等伺服器叫我等的時間 | 回應裡的 Retry-After 標頭就是對方明講「幾秒後再來」——照做比自己猜聰明 |
5xx 伺服器錯 |
重試,指數退避(2 秒、4 秒、8 秒) | 對方暫時壞掉,等一下多半會好 |
404 / 解析失敗 |
立刻放棄,不重試 | 這種錯再試一百次也是同樣結果,重試只是浪費 |
上限三次,三次不成就回 null,讓上層去走「保留舊值」那條路(Day 18 會講為什麼舊值比空值好)。
我特別想強調 429 那條。早期我以為限流就該「直接放棄、等下個排程」,聽起來很禮貌——但那等於白白放棄一整輪資料,而對方其實已經明確告訴你「等 3 秒就好」。很多 API 的錯誤回應裡帶著解法,只是我們習慣只看狀態碼。
還有一個容易寫錯的地方:**非 2xx 的回應要在解析之前就擋掉。**我曾經讓 404 的錯誤頁面進到 JSON 解析階段——有些服務的錯誤頁剛好也是合法 JSON,解析不會噴錯,於是一份「錯誤說明」被當成行情資料寫進快取。這條跟 Day 18 是同一個病:HTTP 200 以外的東西,連看都不要看。
**坑二:時間戳沒寫時區。**快取初版的 timestamp 是裸的 ISO 字串,健檢 job 判斷「快取是否過期」時拿 UTC 去比台北時間,白白誤報了幾次「快取過期」。後來欄位直接改名叫 timestamp_taipei,把時區寫進名字裡——醜,但再也沒人搞錯。
免費行情的生存之道一句話:降低慾望(日線就好)、抓在市場節點上(不對著焦慮猛抓)、集中抓取(單一入口)、溫柔限速(3 併發 + 300ms)、按錯誤類型分流重試、永遠留退路(stale 舊值)。
快取躺好之後,數據要變成每天早上八點準時出現的晨報。明天講盤後 pipeline 的最後一哩:從 JSON 快取到 Telegram 晨報,中間三小時系統做了什麼——以及「程式算數、LLM 寫文」的分工哲學。
🔑 這篇的關鍵字
免費 API 的生存策略:降低慾望(日線 vs 即時)· 集中抓取(單一入口,抓用分離)· 限速(併發數 + 請求間隔,實驗值非公式)
重試按錯誤類型分流:429→ 讀Retry-After標頭照做 ·5xx→ 指數退避 ·404/解析錯 → 立刻放棄
非 2xx 在解析前就擋掉(錯誤頁可能也是合法 JSON)· 時間戳欄位名直接寫時區(timestamp_taipei)· 失敗回null讓上層走保留舊值
我是一名金融業資訊工程師,這是我半年來在家自架 AI Agent 系統的實錄。